iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 24 篇

Day 24|每支 API 都回 401:自己蓋一層攔截器

  • 分享至 

  • xImage
  •  

每支 API 都回 401

假設情境是系統用 Azure AD 登入,前端用微軟的 MSAL 套件處理認證。

在 Angular 專案裡,這件事幾乎不用想。@azure/msal-angular 提供了一個 MsalInterceptor,在設定檔裡告訴它「哪些網址需要帶 token」,之後每一個 HttpClient 發出去的請求,它都會自動去拿 token、自動塞進 header。

到了 React,我裝好 @azure/msal-react,照著範例包好 MsalProvider,登入成功,畫面右上角也出現了我的名字。

然後打開檔案列表:每一支 API 都回 401。

打開 Network 面板,請求的 header 裡沒有 Authorization。我回頭去翻 msal-react 的文件,找那個熟悉的 MsalInterceptor。

沒有。

msal-react 提供了登入、登出、取得帳號資訊的 hook,但沒有任何攔截 HTTP 請求的東西。原因很簡單:React 本身就沒有 HTTP 客戶端,沒有東西可以攔截。

所以這一層,得自己蓋。


一、Angular:攔截器是一條鏈

先回頭看 Angular 是怎麼做的。v15 之後的寫法是函式型的攔截器:

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) router.navigate(['/login']);
      return throwError(() => error);
    }),
  );
};
// app.config.ts
provideHttpClient(
  withInterceptors([authInterceptor, errorInterceptor]),
),

每一個請求依序經過這條鏈:先帶 token,再處理錯誤。全 app 的 HttpClient 都會自動經過,元件完全不用知道它們存在。

而且注意那一行 inject(Router):攔截器活在 Angular 的 DI 裡面,要導頁、要顯示通知、要讀登入狀態,直接注入對應的 service 就好。

這個細節,等一下會變得很重要。


二、React 沒有 HttpClient,但 axios 有攔截器

Day 2 我們建了一個 apiClient:

// lib/apiClient.ts
export const apiClient = axios.create({
  baseURL: `${import.meta.env.VITE_API_URL}/api/`,
  timeout: 240000,
});

React 不管 HTTP,但 axios 管。它本身就有攔截器的機制,分成請求跟回應兩種:

apiClient.interceptors.request.use(config => {
  // 請求出門前
  return config;
});

apiClient.interceptors.response.use(
  response => response,   // 成功的回應
  error => Promise.reject(error),   // 失敗的回應
);

攔截器這個概念沒有消失,只是從框架搬到了 HTTP 套件身上。 就像 Day 14 說的,React 把很多事拆給不同的工具,HTTP 這一層,就是 axios 的責任。

我把要處理的事拆成三層。


三、第一層:帶上 token

MSAL 的 instance 是在 React 外面建立的,本來就是一個模組層級的單例:

// lib/msal.ts
export const msalInstance = new PublicClientApplication(msalConfig);

MsalProvider 用的是它,攔截器也可以直接用它:

apiClient.interceptors.request.use(async config => {
  const account = msalInstance.getActiveAccount();
  if (!account) return config;

  const { accessToken } = await msalInstance.acquireTokenSilent({
    scopes: [import.meta.env.VITE_API_SCOPE],
    account,
  });

  config.headers.Authorization = `Bearer ${accessToken}`;
  return config;
});

axios 的請求攔截器可以是 async 的,它會等 token 拿到了才把請求送出去。

acquireTokenSilent 聽起來像是每次都要去跟 Azure 要 token,其實不會。MSAL 會把 token 快取起來,還沒過期就直接給你,快過期了才在背景換一張新的。

重新整理,401 消失了。


四、第二層:拆包裝,統一錯誤

我們的後端是 Spring Boot,所有 API 都回同一種格式:

{
  "code": "0000",
  "message": "成功",
  "data": { "id": "f-001", "name": "Q3 報告.pdf" }
}

成功的時候 code 是 "0000";業務邏輯上的錯誤,例如「檔名重複」,HTTP 狀態碼一樣是 200,但 code 會是別的值。

在 Angular,我會在攔截器裡把這層包裝拆掉。axios 這邊也一樣:

export class ApiError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
  ) {
    super(message);
  }
}

apiClient.interceptors.response.use(
  response => {
    const body = response.data;
    if (body.code !== '0000') {
      throw new ApiError(response.status, body.code, body.message);
    }
    return body.data;   // 元件拿到的直接是 data
  },
  error => {
    const status = error.response?.status ?? 0;
    const message = error.response?.data?.message ?? '網路連線異常';
    return Promise.reject(new ApiError(status, error.response?.data?.code ?? 'NETWORK', message));
  },
);

這樣不管是 HTTP 失敗(500、404、斷線),還是業務失敗(code 不是 "0000"),元件收到的都是同一種 ApiError。Day 17 表單裡要顯示「檔名已存在」,只要看 error.code 就好。


五、第三層:401 的時候怎麼辦

在 Angular,401 的時候我會 inject(Router),直接導到登入頁。

到了 React,我很自然地想寫:

apiClient.interceptors.response.use(undefined, error => {
  if (error.response?.status === 401) {
    const navigate = useNavigate();   // ❌
    navigate('/login');
  }
});

這行不能跑。hook 只能在元件裡、在 render 的時候呼叫。而 axios 的攔截器,活在 React 外面。

這就是第一節說的那個細節。Angular 的攔截器活在 DI 裡,想要什麼就注入什麼;axios 的攔截器只是一個普通的 JavaScript 函式,它碰不到任何 React 的東西——沒有 hook、沒有 Context、沒有 Router 的導航函式。

所以 401 的處理,我分成兩種情況:

登入失效:交給 MSAL。 401 代表 token 已經不被接受了,這時候導到我們自己的 /login 也沒用,真正要做的是重新跟 Azure 登入。而 msalInstance 本來就在 React 外面,攔截器可以直接用:

if (error.response?.status === 401) {
  await msalInstance.loginRedirect({ scopes: [import.meta.env.VITE_API_SCOPE] });
}

沒有權限:交給路由。 403 不是「你沒登入」,而是「你沒有權限看這個」。這種情況不該在攔截器裡導頁,而是讓錯誤往上丟,由 Day 18 的路由 ErrorBoundary 顯示「你沒有權限檢視這個檔案」。

攔截器只負責 HTTP 層能決定的事。跟畫面有關的決定,交還給 React。


六、通知使用者:也不在攔截器裡做

在 Angular,我的 errorInterceptor 還會順便注入一個通知 service,跳出「系統忙碌中,請稍後再試」的提示。

到了 React,攔截器碰不到元件,自然也沒辦法直接顯示提示。我後來發現,這件事放在 TanStack Query 那一層做更適合:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: error => {
      if (error instanceof ApiError && error.status >= 500) {
        useToast.getState().show('系統忙碌中,請稍後再試');
      }
    },
  }),
});

QueryCache 的 onError 會在任何一個查詢失敗時被呼叫。而 useToast 是一個 Zustand store——Day 12 說過,Zustand 的 store 在元件外面也能用 getState() 操作。

所以最後的分工變成:

層 負責 位置
axios 攔截器 帶 token、拆包裝、統一錯誤格式、登入失效 React 外面
TanStack Query 全域的錯誤通知 半外面,透過 store 跟畫面溝通
路由 ErrorBoundary 權限不足、找不到資料 React 裡面
元件 欄位層級的錯誤,例如「檔名已存在」 React 裡面

在 Angular,這四件事我可能全塞在一個攔截器裡,因為它什麼都注入得到。在 React,攔截器能碰的東西有限,反而逼我把每件事放到該放的那一層。


七、一個順序的坑

我一開始寫了兩個請求攔截器:一個帶 token,一個在 header 加上語系。

apiClient.interceptors.request.use(addToken);
apiClient.interceptors.request.use(addLocale);

結果發現 addLocale 先跑,addToken 後跑。

這是 axios 一個很容易被忽略的行為:請求攔截器是後加的先跑,回應攔截器是先加的先跑。

Angular 的 withInterceptors([a, b]) 是照陣列順序跑的,所以我想都沒想就照著註冊順序寫。目前我的兩個請求攔截器之間沒有依賴,順序對調也不影響,但我在檔案頂端加了一行註解,提醒之後的人這件事。


八、模組單例在這裡安全嗎?

這個系列從 Day 2 就一直在說同一個伏筆:模組層級的單例,在 SSR 的時候是整個伺服器共用一份的。

apiClient 是模組單例、msalInstance 也是模組單例。今天終於可以把這件事講清楚。

我們在 Day 19 選了 SPA 模式。所有程式碼都只在使用者的瀏覽器裡執行,Day 2 說過,模組單例的範圍是「一個瀏覽器分頁」。一個分頁,就只有一個使用者。 所以 apiClient 跟 msalInstance 都只會服務一個人,非常安全。

但如果哪天專案改成 SSR,危險的會是這種寫法:

// ❌ SSR 時,這個變數是整台伺服器共用的
let currentToken: string | null = null;

export function setToken(token: string) {
  currentToken = token;
}

apiClient.interceptors.request.use(config => {
  config.headers.Authorization = `Bearer ${currentToken}`;
  return config;
});

伺服器同時處理 A 跟 B 兩個使用者的請求。A 的請求呼叫 setToken,接著 B 的請求也呼叫 setToken,然後 A 的 loader 打 API——帶出去的是 B 的 token。 A 看到了 B 的檔案。

所以 SSR 時的原則是:跟「哪個使用者」有關的東西,永遠不放在模組層級。 它要從那一次請求本身來,例如在 loader 裡讀請求帶來的 cookie,再傳給 API 的呼叫。

apiClient 本身沒問題,它只是一組設定;有問題的是在模組層級存放某個使用者的狀態。這也是 Day 12 提過的:Zustand 的 store 在 SSR 下一樣是全伺服器共用的。

同一份程式碼,SPA 安全、SSR 危險。部署模式,從來不只是部署的事。


九、對照一下

Angular React + axios
攔截器住在哪 HttpClient,框架內建 axios,HTTP 套件提供
註冊方式 withInterceptors([...]) interceptors.request.use()
執行順序 照陣列順序 請求:後加先跑;回應:先加先跑
能用什麼 inject() 任何 service 只能用模組層級的東西,不能用 hook
MSAL 整合 MsalInterceptor 現成 自己用 acquireTokenSilent
401 導頁 inject(Router) 交給 MSAL,或讓錯誤往上丟
全域通知 攔截器裡注入通知 service TanStack Query 的 QueryCache

今天的結論

Angular 的 HttpInterceptor 是一個什麼都碰得到的地方:它活在 DI 裡,路由、通知、登入狀態,想要什麼就注入什麼。所以我習慣把所有跟 API 有關的事情都塞進去。

React 沒有 HTTP 客戶端,攔截器搬到了 axios 身上。而 axios 的攔截器活在 React 外面,碰不到 hook、碰不到 Context、碰不到路由。

一開始我覺得這是缺點。蓋完之後,我反而覺得這是一種分工:HTTP 層只管 HTTP 的事——帶 token、拆包裝、統一錯誤;要不要導頁、要不要顯示提示,交還給 React 那一側去決定。


上一篇
Day 23|品牌色改了,畫面一半綠一半藍
下一篇
Day 25|如何優雅處理 Token 失效?
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言